¿Qué es el prototype en JS?

Este tema causa muchísima confusión entre los desarrolladores. La razón es simple.

Javascript usa prototypes para implementar el concepto de herencia. Ambos están estrechamente relacionados y si un desarrollador viene de un lenguaje orientado a objetos vendrá con un entendimiento diferente de como funciona el concepto de herencia.

Los lenguajes orientados a objetos suelen utilizar lo que podemos definir como herencia clásica, mientras que JS utiliza lo que se conoce como herencia prototipal o herencia prototípica.

Pero antes de entrar de lleno en lo que es la herencia. Veamos lo básico de lo que es un prototype.

Prototype

Otra de las razones por las que el tema de prototype puede llegar a ser muy confuso es porque tiene múltiples y diferentes nombres y formas de acceso. Te recomiendo que vayas haciéndote a la idea de que tendrás que releer muchas veces los puntos para poder entenderlo bien.

prototype es un propiedad que se encuentra por defecto en cada objeto de Javascript y contiene una referencia a otro objeto. Sin embargo la propiedad no se llama prototype como tal. Su nombre no esta estandarizado, por lo que comúnmente para diferenciarlo de una propiedad se le llama también [[Prototype]], lo cual haremos de este momento en adelante.

La forma estándar y correcta de acceder al [[Prototype]] de cada objeto es a través de la función Object.getPrototypeOf()

*También puedes acceder a través de una propiedad que en los navegadores se conoce como __proto__. Pero acceder de esta forma se considera mala práctica.

Si corres el siguiente código en un navegador, verás que tanto bookPrototypeA como bookPrototypeB apuntan al mismo objeto:

const Book = {    title: 'El Imperio Final' ,
    saga: 'Nacidos de la bruma',
    author: 'Brandon Sanderson',
    sagaId: '1'
}

// Mala práctica. NO usar esto para acceder al [[Prototype]] de un objeto
const bookPrototypeA = Book.__proto__ // Object.prototype

// La forma correcta de acceder al [[Prototype]] de un objeto
const bookPrototypeB = Object.getPrototypeOf(Book) // Object.prototype

JS por defecto hace que el [[Protoype]] de todos los nuevos objetos apunte a un objeto llamado Object.prototype. Este objeto es el que puedes ver al inspeccionar el contenido de bookPrototypeA y bookPrototypeB.

Como podrás observar, Object.protoype contiene estas propiedades (entre otras):

hasOwnProperty
isPrototypeOf
toLocaleString
toString
valueOf

¿Alguna vez te preguntaste de dónde viene la popular función toString()? Precisamente de este objeto.

Es gracias a Object.prototype que podemos llamar a toString() en todos los objetos que creamos sin causar ningún error:

const Book = {
    title: 'El Imperio Final' ,
    saga: 'Nacidos de la bruma',
    author: 'Brandon Sanderson',
    sagaId: '1'
}

const bookString = Book.toString() // [object Object]
const bookSomething = Book.toSomething() // TypeError: Book.toSomething is not a function

*Nota que como la función toSomething() no existe JS lanza un TypeError. No obstante toString() no lanza ningún error a pesar de que aparentemente tampoco existe.

Si te fijas en este comportamiento vemos implementado el concepto de herencia. Un objeto, en este caso Book, tiene acceso a las propiedades de otro, en este caso Object.prototype

¿Pero, esto es herencia?

Algo así, aunque si tienes experiencia en lenguajes orientados a objetos podrías preguntar: ¿Qué no parece mas un caso de asociación?

*La asociación es una relación que define que un objeto usa a otro como propiedad y que el primer objeto no depende de la existencia del segundo para funcionar.

Si, si parece. Pero es aquí donde es importante comprender que JS utiliza la herencia prototipal y no la herencia clásica. Eso es lo que hace Javascript pueda hacer que cualquier objeto utilice a Object.prototype de esta manera.

Herencia

Si ya sabes que es herencia puedes irte directamente a la siguiente sección: Herencia clásica. De lo contrario continua.

La herencia es el comportamiento que permite que un objeto tenga acceso a las propiedades de otro objeto, comúnmente referido como objeto padre. Estas propiedades incluyen funciones (también llamadas métodos en otros lenguajes) y variables (que pueden tener valores primitivos u otros objetos).

Como ejemplo podemos observar la relación entre 2 objetos para roles de usuario:

En este escenario, User tiene 3 variables y 2 funciones. Admin por su parte, hereda todas estas propiedades haciendo que tenga 4 variables en total y 3 funciones.

Como puedes ver la herencia permite que se define la funcionalidad que se quiere compartir entre varios objetos en uno solo. Con este ejemplo se pueden crear mas roles de usario que hereden de User : Analyst, Tester, etc.

Herencia clásica

La herencia clásica usa el sistema de clases. Las clases comúnmente son solo modelos. Las propiedades que tienen definidas son solo una guía de como debe crearse cada objeto.

Tomando de referencia el ejemplo anterior:

Este diagrama se traduciría en 2 clases: User y Admin. Al ejecutar el código y crear un objeto Admin, no se necesita una instancia de un User. El lenguaje usado simplemente usa los modelos para saber que propiedades asignarle a Admin.

La instancia del objeto de Admin en realidad se ve así:

Si piensas que eso era obvio, espera a ver como trabaja la herencia prototipal. Por algo es importante tener en cuenta como funciona la herencia clásica.

*Javascript también tiene formas de indicar que se implementen clases y herencia de clases a través de las keywords class y extends respectivamente.
No te confundas. Esto se implementó para que JS proporcionará un entorno familiar a desarrolladores que vinieran de lenguajes orientados a objetos. En el fondo JS no funciona de esta manera.

Herencia prototipal

No dudo que esto ya lo tengas claro pero no esta de mas recalcarlo.

El punto, el objetivo que se cumple con la herencia prototipal es exactamente el mismo que el de la herencia clásica. Sin embargo la forma en la que funciona es diferente.

Tomando como ejemplo nuestro caso de User y Admin. ¿Qué tan diferente es el objeto Admin resultante de la herencia prototipal?

A diferencia de en la herencia clásica el objeto no tiene todas las propiedades que tiene User. En su lugar tienen un [[Protoype]]. Este objeto hace referencia a otro objeto instanciado de tipo User.

Cuando llamas una propiedad dentro del objeto de Admin Javascript primero la busca dentro del mismo objeto y, de no encontrarlo, procede a buscarlo en el [[Prototype]], en este caso User.

Pero como mencionaba anteriormente, el [[Prototype]] de un objeto apunta por default a otro objeto de Javascript llamado Object.prototype. Esto es lo que permite que el primero acceda a propiedades como: toString, valueOf, etc.

Si tu quieres lograr la funcionalidad en el diagrama anterior tienes que cambiar el objeto del [[Protoype]]. Esto lo puedes hacer con la función Object.setPrototypeOf(targetObject, newPrototype).

function Admin() {
    return {
        adminId: '',
        registerNewUser: function() {
            console.log('register new user process')
        }
    }
}

function User() {
    return {
        name: '',
        phoneNumber: '',
        emailAddress: '',
        changePassword: function() {
            console.log('change password process')
        },
        closeAccount: function() {
            console.log('close account process')
        }
    }
}

const user = new User()
const admin = new Admin()

Object.setPrototypeOf(admin, user)

const userPrototype = Object.getPrototypeOf(user)
const adminPrototype = Object.getPrototypeOf(admin)

Esto es lo que ayuda a que admin pueda acceder a las propiedades de user:

admin.closeAccount() // close account process

¿No te queda muy claro? Intenta correr el código de nuevo pero ahora comenta la línea de Object.setPrototypeOf(admin, user) y ve que ocurre:

...
// Object.setPrototypeOf(admin, user)
...
admin.closeAccount() // TypeError: admin.closeAccount is not a function

Ahora perdiste acceso a closeAccount y a las demas propiedades del objeto user.

¡Oye, un momento! Si cambiamos el [[Prototype]] ¿no estamos perdiendo acceso a todo lo que habia en Object.prototype?

Buena observación. La respuestas es en principio sí , pero en la práctica no

Mira intenta llamar a toString con admin

admin.toString() // [object Object]

Sigue comportandose igual que siempre. Debería arrojar un TypeError…¿no?

Nop. Tiene total sentido que no produzca un error.

La respuesta a esto yace en la Prototype Chain.

Prototype Chain

Prototype Chain (Cadena de prototipos en español) es el concepto que define la forma en la que se relacionan los [[Prototype]] de cada objeto.

Ya viste que cuando JS busca una propieda dentro de un objeto lo busca primero dentro del mismo objeto y si no lo encuentra lo busca en su [[Prototype]].

Lo interesante del asunto es que JS no se detiene ahí. Recuerda que un [[Prototype]] es un objeto y por lo tanto tiene su propio [[Prototype]].

Por lo tanto Javascript sigue este patrón de seguir buscando las propiedades dentro del [[Prototype]] de cada objeto que va analizando. Solo se detiene hasta que encuentra un [[Prototype]] que sea null. (Esto comúnmente ocurre con Object.prototype).

Esto es lo que pasa cuando llamas a admin.toString() después de haberle cambiado su [[Protoype]] para que apunte a user.

  1. Se busca la función en admin.
  2. Se revisa el objeto al que el [[Prototype]] de admin apunta.
  3. Se ve que el objeto es user y se busca la función.
  4. Se revisa el objeto al que el [[Prototype]] de user apunta.
  5. Se ve que el objeto es Object.prototype y se busca la función.
  6. Se encuentra la función y se ejecuta sobre el objeto original admin.

¿Fácil no?

No es un concepto nuevo en sí, solo es el nombre que se la da a la relación entre objetos mediante sus [[Prototype]].


JS es curioso. No necesitas entender el concepto de prototype para desarrollar ningún proyecto. Y es mas, muchas aplicaiones pueden salir perfectamente bien sin siquiera tocar esta funcionalidad.

No obstante si entiendes bien como funciona se te abrirá un mundo de posibilidades con Javascript.